perf(desktop): bound renderer in-memory retention with LRU eviction - #5596
Open
wpfleger96 wants to merge 4 commits into
Open
perf(desktop): bound renderer in-memory retention with LRU eviction#5596wpfleger96 wants to merge 4 commits into
wpfleger96 wants to merge 4 commits into
Conversation
The channel-scoped observer archive (`archiveEventsByChannel`) grew only by explicit paged loads from SQLite and was never evicted, so a long session that visits many channels retains every channel's full scroll-back for the lifetime of the app — a renderer memory leak behind the v0.5.9 lag. Bound it to MAX_RETAINED_ARCHIVE_CHANNELS distinct channels via an LRU policy that evicts whole channels (every agent entry for a channel goes together). Channels with a mounted panel are pinned (refcounted) and never evicted; an evicted channel re-hydrates from SQLite on revisit, so eviction is a cache miss, not data loss. A post-unmount fence stops an in-flight page from resurrecting an evicted channel after its pin is released. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The per-agent live-event stores (`eventsByAgent`/`transcriptByAgent`/the memoized snapshot) each kept the full MAX_OBSERVER_EVENTS window, and a long-lived session observes many agents over time — so the accumulator grew with the number of distinct agents seen and never shrank, one of the renderer leaks behind the v0.5.9 lag. Tier the window by whether a session viewer is mounted. A viewer (the two session panels and the activity bar, via useObserverEvents/useAgentTranscript) pins its agent with a refcount, keeping the full window so the deep scroll-back it displays is never truncated under it. An unpinned agent — observed only by background consumers that never display the transcript — is bounded to UNPINNED_AGENT_EVENT_TAIL. Unpinning the last viewer immediately re-bounds the window it accumulated, rebuilding events, transcript, and snapshot atomically before notifying so no consumer sees a transcript referencing dropped events. The tail is safe for every non-viewer consumer: preventSleep reads only the newest event (always retained); the active-turns bridge processes each event once as it arrives, with turn state living in its own store and gated by a per-channel watermark, so a turn_started aging out of the tail cannot lose the turn; and the profile activity feed derives a channel set that degrades to the tail (its durable history is channel-scoped in SQLite). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ndow Every visited channel and thread leaves its query-cache keys in place for gcTime (one hour) after the view unmounts, so a session that roams many channels holds every timeline's window store and flattened render array in RAM at once — a renderer memory growth behind the v0.5.9 scroll/input lag. gcTime bounds how long an inactive query lingers, not how many linger concurrently; this adds the missing concurrency bound. A cache subscription mounted once in CommunityQueryProvider evicts the least-recently-updated unpinned timelines past a fixed cap. Channels and threads are bounded independently so a burst of open threads never evicts channel scrollback. A unit is pinned while any of its queries has a mounted observer or holds an optimistic pending send. Eviction removes both channel keys together and reads back absent, so revisit re-fetches from SQLite and a guarded background write cannot resurrect an evicted key. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
duncan/bound-renderer-accumulators
branch
from
August 12, 2026 05:43
55580f4 to
dfa8469
Compare
…tomic Close the pass-3 review findings on the bounded message-window / observer-archive work. F1: a per-unit generation guard (updateRetainedMessageUnit / messageUnitGuard) fences deferred writers — reaction and aux hydration, ancestor loads, edit and delete mutation handlers, and the profile-wave send — so a write that settles after its unit was evicted can neither resurrect the dropped key nor stale-merge onto a re-fetched timeline. A key-presence backstop in projectChannelWindowMessages covers the refresh path that has no pin. F2: reaction hydration claims are cleared on eviction. F3: ingestArchivedObserverEvents is two-phase — decrypt every frame into a staging buffer, then commit the whole page under a single isStale() gate with no await between check and append, so a channel switch or unmount mid-decrypt drops the page whole rather than tearing it. F4: a durable per-agent channelId to latest-activity-ms summary survives the unpinned event-tail truncation and feeds the profile feed's channel scope. observerRelayStore.ts had grown past the 1000-line file-size ratchet, so the channel-activity summary and the pure event-ordering / batch-unwrap helpers move to channelActivitySummary.ts and observerEventOrdering.ts, re-exported from the store to keep import paths stable. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
duncan/bound-renderer-accumulators
branch
from
August 12, 2026 05:59
dfa8469 to
6c4122f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bounds the desktop renderer's unbounded in-memory retention families with whole-unit LRU eviction and explicit pins, so RAM plateaus during sustained multi-agent use with zero data loss and zero stale-state corruption. Three retention families were unbounded and grew linearly with the number of channels/agents a session touches; this replaces that growth with a fixed working set.
What changed
1. Observer archive → LRU channel window (
observerRelayStore.ts,observerArchiveEviction.ts)archiveEventsByChannelis capped to an LRU window of channels. Eviction pairs deletion with the existingapplyChannelResetgeneration mechanism, so a reopened channel starts at a null cursor withinitialHydrationDonecleared and an in-flight decrypt/ingest cannot repopulate an evicted key. Mounted(agent, channel)keys are pinned.2. Observer live-event window → tiered by viewer pin (
observerRelayStore.ts,useObserverEvents.ts)The live display window keeps the full 3,000-event window only for pinned viewers (explicit keyed viewer refcounts, not listener presence — the listener set has non-viewer consumers). Unpinned agents keep a small tail.
transcriptByAgentis evicted/rebuilt together with the display window andsnapshotByAgentis invalidated atomically, so no second retained representation survives eviction. A compact per-agent operational summary is kept separate from the evictable display history. The non-viewer snapshot consumers (active-turn, prevent-sleep, auto-restart, latest-session, profile feed) are covered by tests proving identical behavior after eviction.3. Message-window query cache → LRU timeline window (
messageWindowEviction.ts,boundMessageWindows.ts,useBoundedMessageWindows.ts)Inactive channels'
channelWindow+ pairedchannelMessageskeys are evicted together as a whole unit (dropping one alone tears state — channel pages are an echoed-cursor chain andchannelWindowStorethrows on discontinuity). Threads are bounded independently. Pins: any of the unit's queriesisActive(), or the unit holds an optimistic pending send (including the cross-key send-from-thread case vialiveOverlay). Reopen re-fetches a clean head from a null cursor; guarded background writes are a verified no-op on an evicted key, so eviction can't be silently resurrected.The eviction policy reuses React Query's own signals —
isActive()for pins,dataUpdatedAtfor recency,datafor pending sends — so there is no parallel bookkeeping to keep in sync. The sweep is mounted once via a 3-line addition inApp.tsx'sCommunityQueryProvider(the inner client all message keys live on).Plateau receipt
Disposable harness drives the real
enforceMessageWindowBoundsagainst a realQueryClientthrough an N-channel roaming session, measured in isolated per-mode processes so each heap delta is attributable to its own cache. Auto-refresh is inherently disabled (Option D is not on this branch).Unbounded retention and heap grow linearly with channels roamed; bounded plateaus at
MAX_RETAINED_MESSAGE_CHANNELS = 12with flat heap regardless of session length.Tests
Eviction tests cover deterministic LRU/pinning, pending-send protection, null-cursor reopen, and history-exhausted semantics after rehydrate. Real-path deferred-writer tests evict a unit mid-flight, resolve the deferred write, and assert both the window-store and flattened-array keys stay absent — one per fenced writer: reactions hydration, structural aux backfill, ancestor loads, and mutation completion.
ingestArchivedObserverEventsgets channel-switch and unmount tests: block inside the decrypt, force the reset/dispose, resolve, and assert the old archive page is dropped whole rather than torn.Post-eviction consumer equivalence. The tiered live-event window truncates an unpinned agent's
eventsByAgent/transcriptByAgent/snapshotByAgentto the newest-N tail. Each of the five non-viewer consumers of that state maps to a covering test:channelActivitysummaryderiveProfileActivityFeedScopefold suite (5 cases) +test_channel_dropped_from_tail_survives_in_summaryevents[last]onlytest_newest_event_survives_tail_for_prevent_sleepactiveAgentTurnsStore(per-notify watermark, independent of the event array)test_active_turns_survive_event_evictiongetActiveTurnsForAgent+ typing store — no event-array readtest_active_turns_survive_event_evictionconnectionStatescalar + working-state — no event-array readgetLatestLiveSessionIdreads the independentlatestLiveSessionByAgentChannelmap (never truncated); the session-panel label derives fromdisplayEventsonly while the agent is pinned (full window)The durable per-agent
channelId → latest-activity-mssummary is fed on the live-append path only and survives tail truncation; archive-ingested channel events are intentionally outside it because they never contributed to feed scope on main (ingestArchivedObserverEventsroutes them to the channel-scoped archive window, not the live store). This scope decision is documented at the summary equivalence suite.Full desktop unit suite: 4779 pass / 0 fail.
scroll-history.spec.ts(smoke): 18 passed.